fix(client): default organizations.invite role to 'member' so the shorter call works - #17270
Conversation
better-auth 1.7.2's body schema for POST /organization/invite-member makes
`role` REQUIRED, while the SDK declared it optional and forwarded the caller's
object verbatim. The shorter call the declaration advertises —
`invite({ email, organizationId })` — was therefore refused with
`400 [body.role] Invalid input` before it reached any ObjectStack code.
`role` stays optional; omitting it now sends 'member', which is exactly what
the sibling `invitations.resend` has always substituted over the same vendor
endpoint. One family, one behaviour, and no published type moves.
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
Co-authored-by: Claude <noreply@anthropic.com>
…mission
A caller writing `invite({ email, role: maybeRole })` with an empty variable
must reach the wire as `role: 'member'`, not as no role at all. This is the
case that separates `{ ...req, role: req.role ?? 'member' }` from the
`{ role: 'member', ...req }` spelling, which agrees with it on every status the
driven cases can observe.
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 Co-authored-by: Claude <noreply@anthropic.com>
…vite-role-default-member
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
ACCEPT — armed for the queueDispatching seat ( CI: 33 check runs on head, 0 red, 0 in progress — every row read, not sampled. The route is the ruled one, and the excluded one stayed excludedThe dispatch fenced this card to default ⭐ Both stop conditions were hit, and both were discharged rather than waved pastThe dispatch attached two conditions that would have stopped delivery. Both fired, and neither was decided quietly:
EvidenceDriven before and after against a real
Ablation direction written down before any leg ran — "RED, 4 failed / 2 passed, failing exactly (1)(3)(4a)(4c)" — and observed exactly that. Prediction held; nothing was amended after the fact. The committed suite re-drives it and adds request-byte pins at full-string equality, plus a compile-time pin that reddens if
Residue#17274 filed — Noted, not filed:
|
Fixes #16582
Clause-②: no
organizations.invitedeclaresrole?as optional, and better-auth 1.7.2's body schema forPOST /organization/invite-membermakesrolerequired. The SDK forwarded the caller's object verbatim, so the documented-looking minimal call was refused before it reached any ObjectStack code. Omittingrolenow sends'member'.⛔ No published type moves.
rolestays optional; the declaration is untouched. The other self-consistent repair — declaringrolerequired — is a narrowing of a published request type and was weighed and rejected on the card; case ⑤ of the new suite is a compile-time pin that reddens if anyone takes it later.Why
'member', and the objection answered out loudDefaulting a
rolemeans implicitly choosing a permission level for the caller. Three things make'member'the answer rather than a guess:organizations.invitations.resendhas always substitutedrole ?? 'member'over the same vendor endpoint. That asymmetry is the whole reason the gap stayed invisible: one member of the family papered over the vendor's requirement and the other did not, so only the shorter form ever failed. Aligning them is 「① 缩小特例」的直接兑现——同一家族一套行为.owner,admin,delegated_admin,member;orgRoleGradestarts atGRADE_MEMBERand rises only forowner/admin. The default cannot confer more reach than the caller asked for.status: 'pending'; the invitee must still accept, and an admin can change the role first.I do not judge implicit role assignment unacceptable here, so the route was not switched.
Driven — before and after, on a real wire
Real
AuthManager(better-auth 1.7.2, organization plugin, teams enabled — 9 team endpoints present) over a realSqlDriver(better-sqlite3), through the realObjectStackClient:Both runs also drove the two controls that must not move: an explicit
role: 'member'(200 before and after) andinvitations.resend(200 before and after).The committed suite
packages/client/src/organization-invite-role-default.test.tsre-drives this rather than mocking it — realAuthManagerover a realObjectQLon a realSqliteWasmDriver, only the socket stood in for. Cases ④a–c additionally hold the request bytes to full-string equality, which is the half the drive structurally cannot see.Caller census — the stop condition, and it did not trip
The card and triage made this the first task, with a positive control, because 「一个未经证明的搜索面给出的零是仪器的零,不是读数」.
packages/client/src/index.tsresend's own call intoinviteis found by the same queryobjectui@ HEAD348725aand @ pin53ded82).invite(→ 0 hits; Console uses its ownuseAuth().inviteMember, which always sends a role (ORG_ROLE_MEMBERdefault)inviteMember→ 55 hits at the pin;@objectstack/client→ 91 files at HEADThe three in-repo hits that are not live callers:
resend(sends a role),return-type-precision.test.ts(type-onlyexpectTypeOf, never issues a request), and apackages/client/CHANGELOG.mdexample — which this change makes true. ⇒ Latent trap, not a path broken today; the p3 grading stands.Ablation — prediction written first, observed as predicted
Predicted before any leg ran: reverting to the pre-fix body turns the suite RED at 4 failed / 2 passed, failing exactly ①, ③, ④a, ④c and passing ② and ④b (both name a role explicitly, so their bytes are unchanged).
Observed:
Tests 4 failed | 2 passed (6)—× ①,× ③,× ④a,× ④c. Prediction held.Mutation proven on disk before the run (fixed spelling
1 → 0, pre-fix spelling16 → 17, blob264f459c…→9f399ef7…). Restore proven after:git checkout HEAD -- ABSOLUTE_PATH, blob back to264f459c…= HEAD's blob,git diff HEADempty, counts back to1/16,git status --porcelainclean. Nodist/preflight applies — the suite imports the subject as./index, so the resolution path is source, not the package'sexports.Verification
Anchored at the final head (post-merge with
origin/main).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon the final tree: 59 families, all green, reconciled with--ran(59 derived / 59 run / 0 NOT-MEASURED / 0 UNRUN). Three of them (check:skill-examples,check:dual-build-cjs-loads,check:type-check-debt) first answered PREREQUISITE NOT MET / OOM against an unbuilt tree — ⛔ recorded as NOT MEASURED, then rebuilt and re-run to a real green.pnpm --filter @objectstack/client test: 41 files / 492 tests passed.typecheck: green, andcheck:test-typecheckcompiles the test layer, so case ⑤'s type pin is really evaluated.eslint . --no-inline-config --format jsonover 6449 files, 0 errors, 0 warnings, exit 0.affected-docs.mjslists 0 rows. The three blind spots checked by hand: the only text this change makes newly TRUE is the generatedpackages/client/CHANGELOG.mdexample; the prose-class sweep acrosscontent/docsanddocs/(which the tool never walks) finds 5 files naming the surface, and every one of them spellsroleexplicitly, so none is stale in either direction.Scope
Request shape only. No response body, no declared return type, no published response contract. ⛔ PR #16576 / card #14314 untouched — it binds return types and its pin's
invite({ email })is a type-only assertion that never issues a request. ⛔ Not merged with #15447 (same seam, opposite failure mode: there the vendor silently strips, here it loudly requires).packages/client/src/index.tsis a measured hard-serial hot spot. Re-swept at claim time and again at the end of the run: 23 open PRs, paged to the end, each read against its own merge base (the one 100-file page fully paged to 207) — zero touch this file. Control: the same method detects it on #17237 and #16694.验收备注
invitations.resendstill spells its ownrole: invitation.role ?? 'member'. It is now redundant, and it is left alone deliberately: removing it is polish on a hard-serial file and changes no behaviour. Worth folding into whatever next edits that method.invitations.resenddeclaresteamId?: string | nulland never forwards it —invitehas noteamIdmember, so a caller's team placement is silently dropped. Noted here rather than filed; see the report comment on the card.Generated by Claude Code